iT邦幫忙

2024 iThome 鐵人賽

DAY 5
1

現在讓我們對元件進行單元測試吧

單元測試的原則為「把待測物當成黑盒子,專注於測試公開介面」,也就是說我們只會針對元件的 template、props、event、對外公開的 method 與屬性,不會測試元件內部的私有屬性和邏輯。

這是因為即使元件內部程式隨著時間變更,只要公開介面維持一致,都能保證測試過關,才不會讓測試本身變得過於脆弱、難以維護。

推薦大家看看這個很棒的演講,還有 Vue Test Utils 的文件

第一個測試

讓我們開始寫測試案例吧!( ´ ▽ ` )ノ

第一步讓我們安裝測試工具。

npm i -D @vue/test-utils @vitest/ui

接著新增第一個測試。

src\components\btn-naughty\btn-naughty.spec.ts

import { mount } from '@vue/test-utils';
import { test, expect } from 'vitest';

import BtnNaughty from './btn-naughty.vue';

test('第一個測試', () => {
  const wrapper = mount(BtnNaughty);
  expect(wrapper).toBeDefined();
})

現在讓我們使用 vitest 執行測試,新增測試用的腳本。

package.json

{
  ...
  "scripts": {
    ...
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest run --coverage"
  },
  ...
}

執行命令。

npm run test:ui

沒意外的話會開啟網頁,呈現以下畫面。

image.png

恭喜我們成功執行第一個測試了!ヾ(◍'౪`◍)ノ゙

第一個測試案例

現在讓我們依照元件的公開介面,依序新增各個測試案例吧。

src\components\btn-naughty\btn-naughty.spec.ts

...

test('設定 label', async () => {
  const wrapper = mount(BtnNaughty);

  const label = '很長很長的 label'

  expect(wrapper.text()).not.toBe(label);

  await wrapper.setProps({ label });
  expect(wrapper.text()).toBe(label);
})

鱈魚:「然後就會發現測試完美的失敗啦!◝(≧∀≦)◟」

路人:「是在驕傲個甚麼鬼?Σ(ˊДˋ;)」

可以在終端機看到詳細錯誤訊息。

FAIL  src/components/btn-naughty/btn-naughty.spec.ts > 設定 label
AssertionError: expected '我是按鈕' to be '很長很長的 label' // Object.is equality

- Expected
+ Received

- 很長很長的 label
+ 我是按鈕

 ❯ src/components/btn-naughty/btn-naughty.spec.ts:14:26
     12| 
     13|   wrapper.setProps({ label });
     14|   expect(wrapper.text()).toBe(label);
       |                          ^
     15| })
     16| 

這是因為我們的元件根本沒有實作顯示 label 功能,讓我們修正一下這個 Bug 吧。(´,,•ω•,,)

src\components\btn-naughty\btn-naughty.vue

<template>
  <!-- 容器 -->
  <div class="relative">
    ...

    <!-- 按鈕容器 -->
    <div ... >
      <slot v-bind="attrs">
        <button class="btn">
          {{ props.label }}
        </button>
      </slot>
    </div>
  </div>
</template>

<script setup lang="ts">
...
const props = withDefaults(defineProps<Props>(), {
  label: '我是按鈕',
  ...
});

...
</script>

...

按下儲存的那一刻,會發現 vitest 已經執行完成了,這次完美通過了!(/≧▽≦)/

 RERUN  src/components/btn-naughty/btn-naughty.vue x17

 ✓ src/components/btn-naughty/btn-naughty.spec.ts (1)
   ✓ 設定 label

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  22:35:38
   Duration  225ms

完成測試

追加一些公開參數讓測試更方便進行。

src\components\btn-naughty\btn-naughty.vue

...

<script setup lang="ts">
...

// #region Methods
defineExpose({
  /** 按鈕目前偏移量 */
  offset: carrierOffset,
});
// #endregion Methods
</script>

...

現在讓我們追加更多的測試案例吧。ԅ(´∀` ԅ)

src\components\btn-naughty\btn-naughty.spec.ts

...

test('設定 zIndex', async () => {
  const zIndex = 9999;
  const wrapper = mount(BtnNaughty, {
    props: { zIndex }
  });

  const carrierEl = wrapper.find('.carrier').element;

  if (!(carrierEl instanceof HTMLElement)) {
    throw new Error('carrierEl 不是 HTMLElement');
  }

  expect(carrierEl.style.zIndex).toBe(zIndex.toString());
})

test('設定 maxDistanceMultiple', async () => {
  const wrapper = mount(BtnNaughty, {
    props: { maxDistanceMultiple: 1, }
  });

  // 由於最大距離是 1,所以觸發兩次後一定會超出範圍,導致返回原點
  await wrapper.find('button').trigger('click');
  await wrapper.find('button').trigger('click');

  expect(wrapper.vm.offset.x).toBe(0);
  expect(wrapper.vm.offset.y).toBe(0);
})

test('disabled 後,觸發 click 會移動', async () => {
  const wrapper = mount(BtnNaughty);

  await wrapper.find('button').trigger('click');

  // 未 disabled 時,應該有 click 事件
  expect(wrapper.emitted()).toHaveProperty('click');

  expect(wrapper.vm.offset.x).toBe(0);
  expect(wrapper.vm.offset.y).toBe(0);

  await wrapper.setProps({ disabled: true });
  await wrapper.find('button').trigger('click');

  // disabled 時,應該有 run 事件
  expect(wrapper.emitted()).toHaveProperty('run');

  // 而且會產生偏移
  expect(wrapper.vm.offset.x).not.toBe(0);
  expect(wrapper.vm.offset.y).not.toBe(0);
})

test('default slot 可修改按鈕 HTML 內容', async () => {
  const wrapper = mount(BtnNaughty, {
    slots: {
      default: '<span class="btn">按我</span>',
    }
  });

  // 預設的 button 不應該存在
  expect(wrapper.find('button').exists()).toBe(false);

  const target = wrapper.find('span');
  expect(target.exists()).toBe(true);
  expect(target.classes()).includes('btn');
})

test('rubbing slot 可修改拓印 HTML 內容', async () => {
  const wrapper = mount(BtnNaughty, {
    slots: {
      rubbing: '<span class="rubbing">拓印</span>',
    }
  });

  // 預設的 button 應該存在
  expect(wrapper.find('button').exists()).toBe(true);

  const target = wrapper.find('span');
  expect(target.exists()).toBe(true);
  expect(target.classes()).includes('rubbing');
})

順利通過!✧*。٩(ˊᗜˋ*)و✧*。

 RERUN  src/components/btn-naughty/btn-naughty.spec.ts x71

 ✓ src/components/btn-naughty/btn-naughty.spec.ts (6)
   ✓ 設定 label
   ✓ 設定 zIndex
   ✓ 設定 maxDistanceMultiple
   ✓ disabled 後,觸發 click 會移動
   ✓ default slot 可修改按鈕 HTML 內容
   ✓ rubbing slot 可修改拓印 HTML 內容

 Test Files  1 passed (1)
      Tests  6 passed (6)
   Start at  00:28:58
   Duration  283ms

不過這不代表以上測試已經覆蓋了所有情境,大家還可以想想看有甚麼測試案例,說不定還會發現隱藏的 Bug 喔。( ´ ▽ ` )ノ

總結

  • 完成了第一個測試
  • 完成「調皮的按鈕」基本單元測試

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D05


上一篇
D04 - 調皮的按鈕:開發元件
下一篇
D06 - 調皮的按鈕:更多範例
系列文
要不要 Vue 點酷酷的元件?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言